iT邦幫忙

2026 iThome 鐵人賽

0

一、今日目標

延續 Day 24 完成的醫師查詢 API,今天開始將 Angular 前端與 ASP.NET Core 後端進行整合。

在前面的開發過程中,我已經使用 Figma 完成智慧醫院管理平台的 UI 設計,並逐步將 HTML、CSS 轉換成 Angular Component。

目前醫師查詢頁面已經具備醫師卡片、姓名搜尋、科別篩選、分頁及醫師詳細資訊等畫面。

不過,這些功能原本使用的是 Angular 專案中的 Demo 資料,尚未真正連接後端。

Day 24 已經成功使用 ASP.NET Core Web API 查詢 Oracle Database,完成以下功能:

  • 取得全部醫師。
  • 依醫師姓名搜尋。
  • 依科別篩選。
  • 姓名與科別組合搜尋。
  • 查詢單一醫師詳細資料。

因此,今天希望讓 Angular 真正呼叫這些 API,讓醫師資料不再只是前端寫死的內容。

今日主要目標

  1. 確認 Angular 與 ASP.NET Core 的專案架構。
  2. 建立與後端 DTO 對應的 TypeScript Interface。
  3. 設定 Angular HttpClient。
  4. 建立 DoctorApiService,集中管理醫師 API。
  5. 使用 Angular Proxy 完成前後端連線。
  6. 將醫師列表改為顯示 Oracle 資料。
  7. 串接姓名搜尋、科別篩選及組合搜尋。
  8. 串接醫師詳細資料頁面。
  9. 建立 Oracle 每週固定門診資料表,新增虛構排班。
  10. 建立 ASP.NET Core 每週門診查詢 API。
  11. 將 Angular 醫師詳細頁的門診時間表串接 Oracle。
  12. 加入 Loading、空結果與錯誤處理。
  13. 保留原有首頁及 Demo 預約功能。
  14. 完成單元測試、後端 HTTP 測試與實際瀏覽器驗證。

這次的開發重點是 前後端資料串接,因此盡量保留 Day 17 以前完成的 HTML、CSS 與 RWD 設計,不重新製作畫面。


二、確認現有的 Angular 專案結構

開始串接 API 之前,我先檢查目前 Angular 專案的結構,確認哪些元件負責畫面、哪些檔案負責資料。

目前主要的前端結構如下:

src/
└── app/
    ├── app.config.ts
    ├── app.routes.ts
    │
    ├── core/
    │   ├── models.ts
    │   ├── catalog.service.ts
    │   ├── doctor-api.models.ts
    │   └── doctor-api.service.ts
    │
    ├── doctor-search/
    │   ├── doctor-search.ts
    │   ├── doctor-search.html
    │   ├── doctor-search.css
    │   ├── doctor-results.ts
    │   ├── doctor-results.html
    │   └── doctor-results.css
    │
    └── pages/
        ├── doctors.ts
        ├── doctors.html
        ├── doctors.css
        ├── doctor-detail.ts
        ├── doctor-detail.html
        └── doctor-detail.css

其中,doctor-api.models.ts 與 doctor-api.service.ts 是今天新增的檔案。

這次使用的環境為:

項目 版本
Angular Core 21.2.23
Angular CLI/Build 21.2.24
TypeScript 5.9.3
Node.js 24.11.1
ASP.NET Core .NET 9
Oracle Database 19c

專案採用 Standalone Components,透過 bootstrapApplication() 啟動 Angular,並使用 app.config.ts 集中管理應用程式設定。

原本的醫師查詢流程

在串接前,醫師查詢頁的資料流程是:

DoctorSearch
      ↓
CatalogService
      ↓
Angular Demo 醫師資料
      ↓
DoctorResults
      ↓
醫師卡片

這樣的方式適合前期畫面開發,因為不需要啟動後端,就能先完成 UI 及互動功能。

但如果希望醫師資訊由資料庫管理,就不能一直依賴前端 Demo 陣列。

因此,今天的第一個目標是建立正式的 API 資料存取方式,同時避免破壞原有功能。


三、建立 TypeScript Interface,對應後端 DTO

正式串接前,第一個遇到的問題是:

Angular 原本的 Doctor 型別,與 ASP.NET Core 回傳的醫師資料格式不同。

例如原本的 Demo 醫師 ID 使用:

id: 'd1'

但是後端 Oracle 醫師 ID 是數字:

{
  "id": 1
}

除此之外,兩邊的科別及圖片欄位名稱也不同。

Angular 原有 Demo ASP.NET Core API
id: string id: number
department departmentName
illustration illustrationPath
title: string title: string | null
room API 沒有提供
education 詳細 API 才提供
experience 詳細 API 才提供

如果直接將後端回傳的資料當成原有 Doctor[],就可能產生型別錯誤,也容易讓其他 Demo 頁面受到影響。

因此,這次決定另外建立 API 專用的 Interface,不修改原本的共用 Doctor 型別。

1. DoctorListItem

新增:

src/app/core/doctor-api.models.ts

export interface DoctorListItem {
  id: number;
  name: string;
  departmentId: number;
  departmentName: string;
  title: string | null;
  illustrationPath: string | null;
  specialties: string[];
}

這個 Interface 對應醫師列表 API 的資料格式。

其中:

id: number;

表示醫師 ID 是數字。

而:

title: string | null;

表示職稱可能是文字,也可能是 null。

這是因為 Oracle 中有些選填欄位沒有資料,後端會將它們回傳為 JSON null。

另外:

specialties: string[];

表示醫師可以有多個專長,也可以完全沒有專長。

例如:

['一般內科-測試', '健康諮詢-測試']

或:

[]

2. DoctorDetail

醫師詳細資料比列表多出學歷與經歷,因此使用 extends 延伸原本的 Interface。

export interface DoctorDetail extends DoctorListItem {
  education: string | null;
  experience: string | null;
}

extends 的好處是可以重複使用列表已有的欄位,不需要將 ID、姓名、科別及專長全部重新宣告。

3. DoctorSchedule

完成醫師基本資料串接後,我又新增DoctorSchedule,對應 Day 25 延伸實作的門診 API:

export interface DoctorSchedule {
  id: number;
  doctorId: number;
  dayOfWeek: number;
  startTime: string;
  endTime: string;
  room: string | null;
}

4. ApiResponse

Day 24 的後端 API 會將資料包在 data 欄位中。

例如醫師列表:

{
  "data": [
    {
      "id": 1,
      "name": "陳測試甲"
    }
  ]
}

而詳細資料:

{
  "data": {
    "id": 1,
    "name": "陳測試甲"
  }
}

因此另外建立泛型:

export interface ApiResponse<T> {
  data: T;
}

未來可以透過不同型別表示 API 回應。

ApiResponse<DoctorListItem[]>

代表 data 裡面是醫師陣列。

ApiResponse<DoctorDetail>

則代表 data 是單一醫師物件。

這次也學到,TypeScript Interface 主要提供編譯時的型別檢查,並不會自動驗證伺服器實際回傳的 JSON 是否完全符合定義。

完成 Interface 後,先執行 TypeScript 型別檢查,確認沒有錯誤,再繼續下一步。


四、設定 Angular HttpClient

完成 API 資料型別後,接著要讓 Angular 具備發送 HTTP 請求的設定。

這次使用 Angular 的 HttpClient。

HttpClient 可以讓前端向後端發送 GET、POST 等 HTTP 請求,並接收 API 回應。

由於目前是 Standalone Angular 專案,因此在 app.config.ts 中集中設定。

app.config.ts

import {
  ApplicationConfig,
  provideBrowserGlobalErrorListeners
} from '@angular/core';

import { provideHttpClient } from '@angular/common/http';
import { provideRouter, withInMemoryScrolling } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideHttpClient(),
    provideRouter(
      routes,
      withInMemoryScrolling({
        scrollPositionRestoration: 'enabled',
        anchorScrolling: 'enabled',
      }),
    ),
  ],
};

這次主要新增:

import { provideHttpClient } from '@angular/common/http';

以及:

provideHttpClient(),

Dependency Injection 是什麼?

Angular 使用 Dependency Injection(依賴注入)管理 Service 與其他依賴。

例如未來需要使用 HttpClient 時,可以寫:

private readonly http = inject(HttpClient);

Angular 就會提供對應的 HttpClient。

這次在 app.config.ts 中明確設定 provideHttpClient(),方便集中管理 HTTP 功能。

不過要注意:

設定 HttpClient 並不代表已經發送 API 請求。

接下來還需要建立 Service,真正使用 HttpClient.get() 呼叫後端。


五、建立 DoctorApiService,集中管理 HTTP 請求

完成 HttpClient 設定後,接著建立:

src/app/core/doctor-api.service.ts

這次沒有讓 DoctorSearch Component 直接寫所有 HTTP 請求,而是新增專門處理醫師 API 的 Service。

這樣可以將資料存取與畫面操作分開。

DoctorApiService 完整程式碼

import { HttpClient, HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';

import {
  ApiResponse,
  DoctorDetail,
  DoctorListItem
} from './doctor-api.models';

export interface DoctorSearchOptions {
  keyword?: string;
  departmentId?: number;
}

@Injectable({ providedIn: 'root' })
export class DoctorApiService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/api/doctors';

  getDoctors(
    options: DoctorSearchOptions = {}
  ): Observable<ApiResponse<DoctorListItem[]>> {
    let params = new HttpParams();

    if (options.keyword !== undefined) {
      params = params.set('keyword', options.keyword);
    }

    if (options.departmentId !== undefined) {
      params = params.set('departmentId', options.departmentId);
    }

    return this.http.get<ApiResponse<DoctorListItem[]>>(
      this.baseUrl,
      { params }
    );
  }

  getDoctorById(
    id: number
  ): Observable<ApiResponse<DoctorDetail>> {
    return this.http.get<ApiResponse<DoctorDetail>>(
      `${this.baseUrl}/${id}`
    );
  }
}

1. @Injectable

@Injectable({ providedIn: 'root' })

表示這個 Service 可以由 Angular 的根注入器提供,讓不同 Component 可以共用。

2. inject(HttpClient)

private readonly http = inject(HttpClient);

透過 Angular 的依賴注入取得 HttpClient,不需要自己建立 HTTP 用戶端。

3. baseUrl

private readonly baseUrl = '/api/doctors';

這次使用相對路徑,而不是直接寫:

http://localhost:5099/api/doctors

原因是接下來會透過 Angular Proxy 轉送請求,避免將本機 Port 寫死在每個元件中。

4. 使用 HttpParams

let params = new HttpParams();

HttpParams 用來處理 Query String。

例如:

params = params.set('keyword', options.keyword);

可以讓 Angular 正確編碼搜尋參數,不需要自己拼接問號或 &。

這次也學到 HttpParams 是不可變物件。

因此 set() 會回傳新的 HttpParams,需要重新指定給變數。

5. 每週門診查詢方法

原本的 Service 完成列表及詳細頁後,我又沿用同一個DoctorApiService增加:

getDoctorSchedules(
  id: number,
): Observable<ApiResponse<DoctorSchedule[]>> {
  return this.http.get<ApiResponse<DoctorSchedule[]>>(
    `${this.baseUrl}/${id}/schedules`,
  );
}

6. 使用 Observable

Observable<ApiResponse<DoctorListItem[]>>

表示這個方法回傳 Observable,而不是立即取得醫師陣列。

Observable 可以讓 Component 訂閱非同步結果。

當 HttpClient 的 Observable 被訂閱後,才會開始發送 HTTP 請求。

這次也認識了 subscribe()、async pipe 以及 RxJS 的基本概念。


六、使用 Angular Proxy 連接 ASP.NET Core

完成 Service 後,接著遇到另一個問題:

Angular 與 ASP.NET Core 使用不同的開發伺服器與 Port。

例如:

Angular:
http://localhost:4200

ASP.NET Core:
http://localhost:5099

如果前端直接呼叫不同來源的後端,瀏覽器可能會受到 CORS 限制。

因此,這次開發階段選擇使用 Angular Proxy。

1. 建立 proxy.conf.json

在 Angular 專案根目錄新增:

proxy.conf.json

內容:

{
  "/api/**": {
    "target": "http://localhost:5099"
  }
}

其中:

  • /api/**:代表 /api 底下的多層路徑。
  • target:實際要轉送的 ASP.NET Core 位址。

例如瀏覽器發送:

/api/doctors

Angular 開發伺服器就會轉送到:

http://localhost:5099/api/doctors

2. 在 angular.json 設定 Proxy

在 Angular 開發伺服器的 serve 設定中加入:

"options": {
  "proxyConfig": "proxy.conf.json"
}

完成後,重新啟動 Angular 開發伺服器,讓新的 Proxy 設定生效。

3. 前後端資料流程

瀏覽器
   ↓
Angular 開發伺服器
   ↓
/api/doctors
   ↓
Angular Proxy
   ↓
ASP.NET Core DoctorsController
   ↓
DoctorRepository
   ↓
Oracle Database

這樣瀏覽器是向 Angular 自己的來源發送請求,再由開發伺服器轉送。

Proxy 與 CORS 的差別

這次開始理解 Proxy 與 CORS 並不是同一件事。

Proxy 是由開發伺服器代為轉送請求。

CORS 則是在瀏覽器直接呼叫不同來源時,由後端透過回應標頭允許特定來源存取。

這次使用 Proxy,因此沒有修改 ASP.NET Core 的 CORS 設定。

另外,Angular Proxy 主要用於本機開發,production build 不會自動建立相同的代理服務。

正式部署時仍需要另外規劃 API 路由。

實際連通測試

這次分別測試直接呼叫 ASP.NET Core,以及透過 Angular Proxy 呼叫。

API 測試結果
/api/doctors 成功取得五位醫師
/api/doctors/1 成功取得陳測試甲詳細資料
姓名+科別組合查詢 成功取得符合條件的醫師
/api/doctors/999 正確回傳 404

三種成功查詢的完整 JSON 與直接呼叫後端一致。

這次也確認 Angular 的六張醫師圖片資產可以正常取得。

Angular Proxy 設定

https://ithelp.ithome.com.tw/upload/images/20261030/20178845T7GFvNyfFf.png

瀏覽器 Network 中成功發送的醫師 API 請求。

https://ithelp.ithome.com.tw/upload/images/20261030/201788451hnPp5BleZ.png


七、將醫師列表從 Demo 改成 Oracle API 資料

完成 Proxy 後,終於開始修改 Angular 醫師查詢頁。

這次希望保留原本的醫師卡片設計,只替換資料來源。

但在修改前,發現一個需要特別注意的地方:

DoctorSearch 是共用 Component,首頁和醫師查詢頁都會使用。

因此不能直接將所有 DoctorSearch 都改成 API 模式,否則首頁原本的展示行為可能受到影響。

1. 使用 dataSource 區分資料來源

這次在 DoctorSearch 中設定:

readonly dataSource = input<'demo' | 'api'>('demo');

預設為 demo,因此首頁可以維持原本行為。

只有醫師查詢頁指定:

<app-doctor-search
  [showResults]="true"
  [directory]="true"
  dataSource="api"
/>

這樣就能讓相同 Component 使用不同資料來源。

2. 使用 signals 保存 API 資料

醫師查詢 Component 新增:

private readonly api = inject(DoctorApiService);
private readonly destroyRef = inject(DestroyRef);

private readonly apiDoctors = signal<DoctorListItem[]>([]);

readonly loading = signal(false);
readonly error = signal('');

這次使用 signals 保存三種狀態:

Signal 用途
apiDoctors 保存 API 回傳的醫師資料
loading 判斷是否正在載入
error 保存錯誤提示

3. 使用 subscribe 接收 API

第一版醫師列表串接的核心:

this.loading.set(true);

this.api
  .getDoctors()
  .pipe(takeUntilDestroyed(this.destroyRef))
  .subscribe({
    next: (response) => {
      this.apiDoctors.set(response.data);
      this.loading.set(false);
    },
    error: () => {
      this.error.set('醫師資料暫時無法載入,請稍後再試。');
      this.loading.set(false);
    },
  });

這段是第一次接入列表時的實作節錄。後續加入搜尋與篩選後,查詢流程改由網址參數及 switchMap 管理。

其中:

this.apiDoctors.set(response.data);

會將 API 回傳的醫師陣列保存到 signal。

Angular 之後會更新依賴這個 signal 的畫面。

而:

takeUntilDestroyed(this.destroyRef)

則讓元件銷毀時取消尚未完成的訂閱。

4. 將 API 欄位對應到原有卡片

原本卡片使用的是:

department
illustration

但是後端提供:

departmentName
illustrationPath

為了不修改原本的共用 Doctor 型別,這次新增卡片專用的顯示型別:

export interface DoctorCard {
  id: number | string;
  name: string;
  department: string;
  illustration?: string | null;
  specialties: readonly string[];
}

再將 API 資料轉換成卡片需要的格式:

this.apiDoctors().map((doctor) => ({
  id: doctor.id,
  name: doctor.name,
  department: doctor.departmentName,
  illustration: doctor.illustrationPath,
  specialties: doctor.specialties,
}))

這樣就能讓原有的 DoctorResults 繼續使用。

而且醫師 ID 保留原本的數字,不需要將 API 的 1 轉成 Demo 的 d1。

5. 第一階段實際結果

原本醫師查詢頁顯示六位 Demo 醫師。

串接後,實際在瀏覽器看到五位 Oracle 虛構醫師。

也確認:

  • 醫師姓名與科別正確。
  • 醫師圖片可以正常顯示。
  • 王測試丁沒有圖片時使用備用圖。
  • 沒有專長的醫師不會造成畫面錯誤。
  • 分頁樣式保留原本設計。
  • 首頁仍然使用原本 Demo 模式。

Angular 醫師查詢頁成功顯示 Oracle 資料庫中的五位虛構醫師。

https://ithelp.ithome.com.tw/upload/images/20261030/201788458liu93kNo7.png


八、串接姓名搜尋與科別篩選 API

完成列表串接後,下一步是將原本的前端篩選,改成由 ASP.NET Core 與 Oracle 處理。

原本的搜尋方式

原先使用 Angular 的 computed(),直接篩選已載入的 Demo 資料。

例如:

this.cards().filter((doctor) => {
  // 根據姓名、科別與專長進行本機篩選
});

這種方式適合資料量較少、且資料都已載入前端的情況。

不過 Day 24 已經完成正式的搜尋 API,因此 Day 25 希望直接將查詢條件交給後端。

新的搜尋流程

使用者輸入姓名或選擇科別
          ↓
Angular Router 更新網址參數
          ↓
queryParamMap 取得條件
          ↓
DoctorApiService
          ↓
ASP.NET Core API
          ↓
Oracle 查詢
          ↓
回傳符合條件的醫師
          ↓
Angular 更新卡片

這次決定讓網址成為搜尋條件的主要來源。

1. 使用 Router Query Params

例如:

/doctors?keyword=陳

表示搜尋姓名包含「陳」的醫師。

/doctors?departmentId=1

表示搜尋科別 ID 為 1 的醫師。

而:

/doctors?keyword=陳&departmentId=1

表示兩個條件需要同時符合。

更新網址的主要程式碼:

void this.router.navigate(['/doctors'], {
  queryParams: {
    keyword: this.keyword.value.trim() || null,
    departmentId,
  },
});

這裡的 null 表示移除該查詢條件。

為什麼要讓網址保存搜尋條件?

如果搜尋條件只保存在 Component 裡,重新整理頁面後,可能需要另外處理狀態還原。

但如果網址就是:

/doctors?keyword=陳&departmentId=1

Angular 就可以根據網址重新取得條件,再呼叫 API。

這次也實際測試了:

  • 網址重新整理。
  • 瀏覽器上一頁。
  • 瀏覽器下一頁。

搜尋框內容、科別選取與卡片結果都能恢復到對應狀態。

2. 使用 switchMap 避免過時結果

串接 API 後,搜尋不再是立即篩選本機陣列,而是需要等待 HTTP 回應。

如果使用者快速切換搜尋條件,可能發生:

先搜尋「陳」
       ↓
再搜尋「王」
       ↓
「王」的結果先回來
       ↓
「陳」的結果後回來

如果沒有妥善處理,就可能讓舊結果覆蓋最新搜尋結果。

因此這次使用 RxJS 的 switchMap。

它會在新條件進來時,取消前一次內部 Observable 的訂閱,改為處理最新請求。

查詢核心仍然呼叫原本的 Service:

return this.api.getDoctors(query.options).pipe(
  map((response) => ({
    doctors: response.data,
    error: '',
  })),
);

完成後再更新 signals:

.subscribe((result) => {
  this.apiDoctors.set(result.doctors);
  this.error.set(result.error);
  this.loading.set(false);
});

這裡沒有另外建立新的 API Service,而是沿用先前完成的 DoctorApiService。

3. 保留穩定的科別選項

這次還遇到另一個問題:

如果科別按鈕直接從搜尋結果產生,搜尋後某些科別可能突然消失。

因此 API 模式首次載入時,會先取得完整醫師列表,從資料中的 departmentId 與 departmentName 建立科別選項。

後續搜尋結果只更新醫師卡片,不覆蓋原本的科別清單。

目前 Oracle 測試資料包含:

科別 ID 科別名稱
1 測試內科
2 測試外科
3 測試兒科

因為目前尚未建立獨立的科別查詢 API,所以這些選項是從完整醫師列表取得。

這個方式暫時適合目前的開發階段,但如果未來有完全沒有醫師的科別,仍需要獨立的科別資料來源才能完整顯示。

4. 調整搜尋提示文字

原本的搜尋提示是:

搜尋醫師姓名、科別或專長

但 Day 24 的後端 keyword 目前只搜尋醫師姓名。

因此這次將 API 模式的提示調整為:

搜尋醫師姓名

避免畫面顯示的功能與實際 API 不一致。

首頁 Demo 模式則保留原本的搜尋提示及本機搜尋方式。

5. 實際搜尋結果

搜尋條件 實際結果
無條件 五位醫師
keyword=陳 陳測試甲、陳測試丙
departmentId=1 陳測試甲、林測試乙
departmentId=2 陳測試丙、王測試丁
departmentId=3 李測試戊
陳+科別 1 陳測試甲
王+科別 2 王測試丁
陳+科別 3 空結果
不存在的姓名 空結果

這些條件已經在瀏覽器實際操作驗證。

輸入「陳」並選擇測試內科,Angular 透過後端 API 顯示符合條件的醫師。

https://ithelp.ithome.com.tw/upload/images/20261030/20178845FCmAFqwHsZ.png


九、串接醫師詳細資料頁面

完成醫師列表及搜尋後,最後開始處理醫師詳細頁。

原本 Angular 的醫師詳細頁使用 Demo 資料。

例如:

/doctors/d1

會從 CatalogService 取得對應的 Demo 醫師。

但是現在 API 醫師使用的是:

/doctors/1

因此不能直接將數字 1 當成原本的 d1。

1. 區分 API 與 Demo 醫師

這次沒有直接刪除原有 Demo 詳細頁,而是讓路由依照 ID 格式決定資料來源。

/doctors/1
     ↓
DoctorApiService
     ↓
Oracle 醫師資料

/doctors/d1
     ↓
CatalogService
     ↓
Demo 醫師資料

這樣可以讓新的 API 詳細頁正常運作,同時保留原本尚未改造的 Demo 預約流程。

2. 使用 ActivatedRoute 取得 ID

詳細頁的核心流程:

this.route.paramMap.pipe(
  map((params) => params.get('id') ?? ''),
  distinctUntilChanged(),
  switchMap((rawId) => {
    // 驗證 ID,區分 API 與 Demo。
    // API 數字 ID 使用 getDoctorById()。
    // Demo 字串 ID 保留原有資料流程。
  }),
  takeUntilDestroyed(this.destroyRef),
).subscribe((state) => this.state.set(state));

這裡是主要程式的節錄,省略了實際分流與錯誤處理內容。

其中:

  • paramMap:讀取網址中的醫師 ID。
  • map():取出路由參數。
  • distinctUntilChanged():避免相同 ID 重複處理。
  • switchMap():當路由 ID 改變時切換查詢。
  • takeUntilDestroyed():元件銷毀時解除訂閱。
  • state.set():更新詳細頁畫面狀態。

這次也學到,Angular Router 取得的路由參數原本是字串,因此需要先驗證,再轉換成 API 使用的數字 ID。

3. 顯示 Oracle 詳細資料

成功取得醫師後,詳細頁可以使用 DoctorDetail 中的資料顯示:

  • 醫師姓名。
  • 科別。
  • 職稱。
  • 學歷。
  • 經歷。
  • 專長。
  • 醫師圖片。

其中,學歷與經歷是列表 API 沒有提供、只有詳細 API 才有的欄位。

4. 處理沒有資料的欄位

這次刻意使用王測試丁進行測試。

因為這位虛構醫師的:

  • 職稱是 null。
  • 學歷是 null。
  • 經歷是 null。
  • 圖片路徑是 null。
  • 專長是空陣列。

Angular 頁面不能直接顯示出 null 或 undefined。

例如職稱可以寫:

<p>{{ item.title || '職稱資料尚待補充' }}</p>

專長則可以使用 Angular 的 @for 與 @empty:

@for (specialty of item.specialties; track specialty) {
  <li>{{ specialty }}</li>
} @empty {
  <li>專長資料尚待補充</li>
}

圖片為 null 時,則沿用原本的備用圖片。

這次沒有重新修改 CSS,而是透過必要的資料綁定與文字提示,保留原本的詳細頁版型。

5. 不將 API 醫師連到 Demo 預約

還有一個重要的設計決定:

目前 API 醫師詳細頁不顯示預約入口。

原因是原本的 BookingPage 仍然使用 Demo 資料,而且目前尚未建立正式的預約掛號 API。

因此不能讓使用者點選 API 醫師後,直接進入沒有對應資料的 Demo 預約頁。

在醫師詳細頁初次串接完成時,確實還沒有排班 API,因此當時先不顯示 Demo 門診時間表。後來我在同一天延伸完成每週固定門診資料表、排班 API 與 Angular 串接,現在 API 醫師可以顯示來自 Oracle 的每週門診資訊,但仍然沒有正式預約入口;每週固定排班不等於某一天有名額的可預約診次。

6. 實際詳細頁測試

網址 實際結果
/doctors/1 陳測試甲完整資料
/doctors/2 林測試乙完整資料
/doctors/3 陳測試丙完整資料
/doctors/4 王測試丁,空值正常處理
/doctors/5 李測試戊完整資料
/doctors/999 顯示找不到醫師
/doctors/d1 原有 Demo 詳細頁正常

這些醫師詳細資料已在瀏覽器實際驗證。

Angular 醫師詳細頁成功顯示 Oracle 資料,包含醫師學歷、經歷與專長。

https://ithelp.ithome.com.tw/upload/images/20261030/20178845sFh1Y1ZblP.png


十、延伸實作:將 Oracle 每週門診資料串接至 Angular

完成醫師列表與詳細資料後,我發現原本 Figma 設計的醫師詳細頁還有一張每週門診時間表,但新 API 醫師一開始沒有顯示。原本的 demoSessions 是所有 Demo 醫師共用的三列、六欄展示矩陣,不能直接當成每位醫師的真實排班。因此,我決定在 Day 25 再延伸實作「每週固定門診」的資料庫、後端 API 與 Angular 串接。

1. 先區分每週固定門診與日期化診次

先前 Day 22 規劃的 DOCTOR_SCHEDULES 是有實際日期的診次,未來預約掛號才能連結具體診次與名額。這次需要的則是「每週星期二上午固定看診」這類週期資訊,因此另外建立 DOCTOR_WEEKLY_SCHEDULES,不改動 Day 22 的日期化診次草稿。

新表主要有六個欄位:

欄位 用途
SCHEDULE_ID 每筆週期排班的主鍵
DOCTOR_ID 外鍵,對應 DOCTORS
DAY_OF_WEEK 1~6,代表星期一至星期六
START_MINUTE 從午夜起算的開始分鐘
END_MINUTE 從午夜起算的結束分鐘
ROOM 診間資訊,可以為 NULL

這次沿用目前 UI 的三個固定時段:09:00–12:00、14:00–17:00、18:00–21:00。Oracle 資料表使用分鐘數儲存,例如 09:00 是 540、12:00 是 720;不需要為每週固定排班填入假的日期。

建表時另外設定主鍵、外鍵、唯一限制與 CHECK,避免不存在的醫師、無效星期、錯誤時段及同醫師重複排班被寫入。

2. 在 SQL Developer 建表與新增八筆虛構排班

我先讓 Codex 產生獨立 SQL 腳本,確認表名及限制後,再自行使用 SQL Developer 的 SMARTCARE_DEV 連線執行。

實際執行結果:

Schema:SMARTCARE_DEV
CREATE TABLE:Table DOCTOR_WEEKLY_SCHEDULES created.
欄位:共 6 欄,前 5 欄 NOT NULL,ROOM 可為 NULL
INSERT ALL:已插入 8 個資料列
COMMIT:成功

五位虛構醫師的排班筆數如下:

醫師 門診筆數 測試用途
陳測試甲(ID 1) 3 多個星期與時段
林測試乙(ID 2) 2 不同醫師排班
陳測試丙(ID 3) 1 單一門診
王測試丁(ID 4) 0 無排班的空狀態
李測試戊(ID 5) 2 包含一筆 NULL 診間

SQL Developer 的 GROUP BY doctor_id 查詢中只會看到 ID 1、2、3、5,因為沒有排班的醫師 4 不會出現在排班表中;如果希望一起列出 0 筆,必須從 DOCTORS 使用 LEFT JOIN。

3. 建立 ASP.NET Core 每週門診 API

接著,我在既有 DoctorsController 與 DoctorRepository 中新增查詢功能,並建立 DoctorScheduleDto,沒有重新建立另一個資料庫框架。

GET /api/doctors/{id}/schedules

Repository 的核心 SQL 是:

SELECT d.doctor_id,
       w.schedule_id,
       w.day_of_week,
       w.start_minute,
       w.end_minute,
       w.room
FROM doctors d
LEFT JOIN doctor_weekly_schedules w
  ON w.doctor_id = d.doctor_id
WHERE d.doctor_id = :doctorId
ORDER BY w.day_of_week, w.start_minute, w.schedule_id

我使用 OracleParameter 繫結 doctorId,不直接將輸入拼接成 SQL。LEFT JOIN 讓後端可以明確區分三種情況:醫師存在且有排班 → 200、有資料;醫師存在但沒有排班 → 200、data: [];醫師不存在 → 404。

資料庫裡的分鐘數在後端轉成 HH:mm,所以醫師 1 的 API 實際回應包含:

{
  "data": [
    {
      "id": 2501001,
      "doctorId": 1,
      "dayOfWeek": 2,
      "startTime": "09:00",
      "endTime": "12:00",
      "room": "測試 201 診間"
    },
    {
      "id": 2501002,
      "doctorId": 1,
      "dayOfWeek": 4,
      "startTime": "14:00",
      "endTime": "17:00",
      "room": "測試 201 診間"
    },
    {
      "id": 2501003,
      "doctorId": 1,
      "dayOfWeek": 6,
      "startTime": "18:00",
      "endTime": "21:00",
      "room": "測試 201 診間"
    }
  ]
}

這次新增 API 後,31/31 項 HTTP 檢查與 3/3 項 Mock 故障注入檢查通過。Mock 測試不代表真的停止 Oracle;測試同時確認原本醫師列表、搜尋和詳細資料 API 沒有受到影響。

4. Angular 將排班資料轉換為原本的時間表

有了後端 API 後,我在 doctor-api.models.ts 新增前述 DoctorSchedule 型別,並使用 DoctorApiService.getDoctorSchedules(id) 發送請求。

詳細頁的資料流程變成:

路由 ID
  ↓
取得醫師詳細資料
  ↓
先顯示姓名、學歷、經歷與專長
  ↓
GET /api/doctors/{id}/schedules
  ↓
獨立排班狀態 signal
  ↓
computed 轉成三列、六欄的表格矩陣
  ↓
Angular HTML 顯示門診圓點與診間

這次沒有修改原有 CSS,而是在 TypeScript 中依「星期+起訖時間」尋找排班,讓 HTML 專注於顯示:

schedules.find((schedule) =>
  schedule.dayOfWeek === index + 1 &&
  schedule.startTime === session.start &&
  schedule.endTime === session.end
) ?? null

有排班的格子沿用原有圓點,再加入診間文字:

<span class="clinic-dot" aria-hidden="true"></span>
<span class="sr-only">每週固定門診</span>
<div class="clinic-room" [style.overflow-wrap]="'anywhere'">
  {{ slot.room ?? '診間未提供' }}
</div>

目前 Angular 顯示的是每位醫師各自的 Oracle 排班,不再共用 Demo 時段矩陣。

5. 排班的 Loading、空資料與錯誤分開處理

這次特別保留醫師詳細資料與排班資料的獨立狀態。醫師基本資料成功後可以先顯示,不必等排班 API;若排班查詢失敗,也不會清除姓名、學歷與經歷。

排班狀態 畫面顯示
載入中 門診時間載入中…
200 且有資料 顯示每週門診表
200 且空陣列 目前尚無每週固定門診資料
503 或網路錯誤 門診時間暫時無法載入,請稍後再試

切換不同醫師時,沿用 switchMap 與 takeUntilDestroyed 避免舊請求覆蓋新結果。Demo 醫師仍使用原本的 demoSessions 與 Demo 預約連結;API 醫師雖然已有每週門診資訊,但仍不開放正式預約。

6. 完成門診時間表與手機版驗證

這次已在真實瀏覽器逐一打開五位 API 醫師的詳細頁:醫師 1 顯示週二上午、週四下午、週六晚上;醫師 2、3 分別顯示兩筆、一筆;醫師 4 顯示無排班;醫師 5 的 NULL 診間顯示「診間未提供」。/doctors/d1 仍保留 Demo 排班與預約連結,而五個 API 醫師頁都沒有預約入口。

手機版以 390px 視窗驗證,門診表格內容寬 640px,可以在區塊內水平捲動,整個頁面沒有水平溢出。排班 API 的 503、網路失敗及請求競爭則由 Mock 單元測試驗證,沒有刻意中斷 Oracle。

成果截圖

一般門診時間表:
https://ithelp.ithome.com.tw/upload/images/20261030/20178845LQjgF6eCb8.png

無排班畫面:

https://ithelp.ithome.com.tw/upload/images/20261030/20178845XR41aEGeOk.png


十一、Loading、空結果與 HTTP 錯誤處理

前端開始串接 API 後,與原本使用 Demo 資料最大的差別,就是資料不一定能立即取得。

因此今天也加入及整理了不同狀態的顯示方式。

1. Loading

當 API 尚未完成時,顯示:

@if (loading()) {
  <p role="status">醫師資料載入中…</p>
}

讓使用者知道目前正在讀取資料,而不是誤以為沒有醫師。

2. 空結果

如果 API 成功回傳:

{
  "data": []
}

表示查詢成功,但沒有符合條件的醫師。

因此顯示原有的「找不到符合的醫師」畫面。

3. API 錯誤

如果 API 無法正常取得資料,則顯示錯誤提示。

例如:

<p role="alert">
  醫師資料暫時無法載入,請稍後再試。
</p>

這次特別注意,不能在 API 發生錯誤時直接顯示 Demo 醫師資料。

否則使用者可能以為那些資料就是正式查詢結果。

4. HTTP 狀態碼

這次主要處理:

HTTP 狀態 用途
200 查詢成功
400 查詢參數或醫師 ID 不合法
404 指定醫師不存在
503 後端目前無法完成查詢
網路失敗 無法正常取得 API 回應

這些狀態不應全部當成「查無資料」。

因為沒有符合的醫師與資料庫連線失敗,是兩種完全不同的情況。

另外,這次在 Component 中將錯誤處理放在單次查詢流程內,避免一次失敗後,後續搜尋就無法繼續使用。


十二、單元測試與實際瀏覽器驗證

完成前後端串接後,這次除了檢查 TypeScript 與 Angular build,也使用 Angular 的 HTTP Testing API 測試 Service 及 Component。

1. DoctorApiService 單元測試

這次使用 Angular 的 HttpTestingController 模擬 HTTP 回應。

測試設定:

TestBed.configureTestingModule({
  providers: [
    provideHttpClient(),
    provideHttpClientTesting(),
  ],
});

例如:

const response = firstValueFrom(service.getDoctors());

const request = httpTesting.expectOne({
  method: 'GET',
  url: '/api/doctors',
});

request.flush({ data: [doctor] });

expect(await response).toEqual({ data: [doctor] });

這種方式可以確認 Service 是否發送正確的請求,而不需要真的啟動 Oracle Database。

本次 Service 共完成23個單元測試。

2. 搜尋 Component 測試

搜尋階段另外測試:

  • 姓名搜尋參數。
  • 科別篩選。
  • 組合搜尋。
  • 搜尋條件重新整理。
  • 上一頁與下一頁。
  • 查無資料。
  • API 400、503。
  • 快速搜尋時取消舊請求。
  • 首頁 Demo 模式保留。

完成搜尋階段後,完整 Angular 測試達到 74 個,全部通過。

3. 詳細頁 Component 測試

醫師詳細頁另外新增27個測試,包含:

  • 五位 API 醫師。
  • 不存在醫師。
  • 無效路由 ID。
  • NULL 欄位。
  • 空專長。
  • 備用圖片。
  • API 400、503。
  • 請求取消。
  • Demo 詳細頁保留。
  • API 預約入口限制。
  • 各醫師每週門診矩陣與診間。
  • 無排班、NULL 診間與排班錯誤。
  • 排班請求切換及 Demo 相容性。

十三、這次開發遇到的問題

1. 前端 Demo 與 API 資料格式不同

一開始發現,原本的 Doctor 型別與後端 DTO 不完全相同。

尤其是 ID、科別、圖片欄位及 NULL 的處理。

這次透過獨立的 TypeScript Interface 與 DoctorCard 顯示型別,避免直接修改原有 Demo 型別,減少其他頁面受到影響。

2. 首頁與醫師查詢頁共用 Component

DoctorSearch 原本同時用在首頁及醫師查詢頁。

如果直接將資料來源全部替換成 API,可能會讓首頁也需要等待後端資料。

因此加入 dataSource 模式,讓首頁維持 Demo,完整醫師查詢頁才使用 API。

3. API 第一次載入時後端尚未啟動

醫師列表第一次進行瀏覽器測試時,因為後端尚未啟動,所以畫面正確顯示錯誤訊息。

啟動 ASP.NET Core 後重新整理,才成功顯示五位醫師。

這讓我理解到,前端建置成功與 API 真正連線成功是不同的事情。

4. 搜尋條件需要與網址同步

原本搜尋主要使用前端表單與 computed 篩選。

但改成後端 API 後,如果沒有統一狀態來源,可能導致網址、搜尋框與卡片結果不一致。

因此改為由 Router Query Params 管理搜尋條件,按鈕只負責更新網址,再由網址變更觸發 API 查詢。

這樣重新整理及瀏覽器歷史返回時,也可以恢復對應條件。

5. 快速搜尋可能出現過時結果

非同步 HTTP 請求不一定依照發送順序完成。

因此使用 switchMap,在新的搜尋條件出現時取消前一個查詢訂閱,避免舊結果覆蓋新結果。

6. Angular 測試的導航時機問題

搜尋階段第一次執行完整測試時,有兩個案例失敗。

後來確認問題與測試等待 Router 導航完成的時間點有關。

另外,歷史返回測試還發現 RouterTestingHarness 不會自動建立需要的瀏覽器歷史事件監聽。

因此修正測試端的等待流程與事件監聽後,相關測試才順利通過。

這次沒有為了讓測試成功,就任意修改原本正常的搜尋功能。

7. 門診排班不等於可預約診次

原本 Demo 詳細頁有預約功能及展示用門診時間表,但新 API 醫師一開始既沒有日期化診次,也沒有門診排班 API。後來我另外建立DOCTOR_WEEKLY_SCHEDULES,讓詳細頁顯示各醫師每週固定的門診時間。

不過每週固定門診只有星期、時段與診間,還沒有特定日期、掛號名額與停診例外,所以不能直接拿來建立預約。這次讓 API 醫師顯示每週門診,仍保留「尚未開放線上預約」的界線;Demo 預約功能則不受影響。

8. 後端程序占用與 Angular 樣式預算警告

本次新增排班 API 時,原有後端程序占用預設建置輸出目錄,改用獨立輸出資料夾後成功建置。前端整合階段也曾出現 CSS 預算警告,修正後 production build 沒有警告,並沒有直接提高樣式預算。


十四、今日學習與 AI 協助

今日學習

這次主要學習 Angular 與 ASP.NET Core 前後端整合的基本概念。

Angular 與 TypeScript

  • TypeScript Interface。
  • Interface extends。
  • 泛型 ApiResponse<T>。
  • HttpClient。
  • HttpParams。
  • Angular Service。
  • Dependency Injection。
  • Observable。
  • subscribe。
  • signals。
  • OnPush。
  • Router Query Params。
  • ActivatedRoute。
  • switchMap。
  • takeUntilDestroyed。
  • Loading 與錯誤狀態。
  • computed 排班矩陣及表格資料綁定。
  • 空陣列與 nullable 診間的顯示處理。

前後端整合

  • Angular Proxy。
  • Proxy 與 CORS 的差別。
  • HTTP Request 與 Response。
  • JSON 欄位對應。
  • API 回傳型別與前端顯示型別的區別。
  • REST API 的 GET 查詢。
  • HTTP 200、400、404、503。
  • Demo 資料與 API 資料的分流。
  • API 實際連通測試與 Mock 單元測試的差別。
  • Oracle 每週固定排班的資料表與限制。
  • LEFT JOIN 區分不存在醫師與無排班醫師。
  • 每週固定門診和日期化可預約診次的差別。

AI 協助

這次使用 Codex 協助檢查 Angular 專案架構、規劃分階段修改、建立 DoctorApiService、設定 Proxy、串接 Component、準備每週排班 SQL、擴充 ASP.NET Core API,以及補充單元測試。Oracle 建表、INSERT 驗證及 COMMIT 則由我在 SQL Developer 手動完成。

不過,這次沒有直接讓 Codex 一次修改所有頁面,而是按照:

檢查專案
   ↓
建立資料型別
   ↓
設定 HttpClient
   ↓
建立 Service
   ↓
設定 Proxy
   ↓
串接醫師列表
   ↓
串接搜尋與篩選
   ↓
串接詳細頁
   ↓
建立 Oracle 每週排班資料表
   ↓
擴充門診查詢 API
   ↓
串接 Angular 門診時間表
   ↓
驗證與測試

的方式逐步完成。

每完成一小步,就先確認程式碼、測試結果及是否影響原有功能,再進入下一步。

這種方式雖然需要比較多次檢查,但可以比較清楚地理解每一個新增檔案的用途,也更容易找出問題來源。


十五、今日成果與下一步

今日完成

今天已經成功讓 Angular 醫師查詢頁使用 ASP.NET Core API 回傳的 Oracle 資料。

主要完成:

  1. 建立醫師 API 專用 TypeScript Interface。
  2. 設定 Angular HttpClient。
  3. 建立 DoctorApiService。
  4. 使用 HttpParams 處理查詢條件。
  5. 設定 Angular 開發 Proxy。
  6. 驗證 Angular 能夠連接 ASP.NET Core。
  7. 將醫師列表從 Demo 資料改成 Oracle 資料。
  8. 保留首頁原本的 Demo 搜尋方式。
  9. 串接姓名搜尋 API。
  10. 串接科別篩選與組合搜尋。
  11. 使用 Router Query Params 保存搜尋條件。
  12. 使用 switchMap 處理過時的非同步請求。
  13. 串接醫師詳細資料 API。
  14. 正確處理 NULL、空專長與備用圖片。
  15. 區分 API 醫師與 Demo 醫師路由。
  16. 加入 Loading、空結果與錯誤處理。
  17. 在 Oracle 建立 DOCTOR_WEEKLY_SCHEDULES,新增並提交八筆虛構排班。
  18. 完成 GET /api/doctors/{id}/schedules。
  19. 將各醫師的每週固定門診顯示於 Angular 詳細頁原有表格。
  20. 驗證無排班、NULL 診間、排班錯誤與 Demo 模式分流。
  21. 完成 107 個 Angular 單元測試,以及 31 項後端 HTTP 檢查、3 項 Mock 故障注入檢查。
  22. 在瀏覽器驗證醫師列表、搜尋、詳細頁、門診資訊與手機版表格。

尚未完成

目前還沒有完成:

  • API 醫師的正式線上預約掛號。
  • 指定日期的實際診次、名額、停診例外與預約掛號 API(不同於已完成的每週固定門診)。
  • 正式部署環境的 API 路由。
  • 除已驗證的 390px 手機門診表格外,其餘 RWD 尺寸的完整瀏覽器驗收。

這些功能都留到後續階段再處理,沒有在這次串接中直接補上假的資料。

下一步規劃

Day 25 已完成原定的醫師查詢前後端整合。

接下來將依照 30 天開發規劃,逐步擴充智慧醫院預約網站的其他功能。

後續會優先檢查目前前端預約流程與資料庫設計的對應關係,再決定下一個適合開發的後端模組。

這次最大的收穫,是讓自己設計的 Angular 畫面真正顯示 Oracle Database 中的醫師與每週門診資料,而且能依醫師顯示不同排班。

從最初使用 Figma 設計畫面、HTML/CSS 排版,到 Angular Component、ASP.NET Core Web API、Oracle 資料表,現在終於把這些部分串接起來。

也開始理解,真正的前後端整合不只是讓畫面顯示 JSON,還需要考慮資料型別、共用元件、路由、非同步請求、錯誤處理,以及既有功能是否受到影響。

這也是 Day 25 最重要的開發成果:在保留原有 UI 與 Demo 功能的前提下,完成醫師查詢、詳細資料與每週門診時間表的前後端整合。


上一篇
Day 24|醫師查詢 API
下一篇
Day 26|登入與註冊 API
系列文
從 Figma 到 Angular:打造智慧醫院預約掛號系統的 30 天實作之旅 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言